Skip to content

fix(driver-memory): analytics honours the declared AnalyticsQuery.timezone when resolving a string dateRange (#16042) - #16174

Merged
zhuangjianguo merged 2 commits into
mainfrom
claude/issue-16042-analytics-timezone
Sep 6, 2026
Merged

fix(driver-memory): analytics honours the declared AnalyticsQuery.timezone when resolving a string dateRange (#16042)#16174
zhuangjianguo merged 2 commits into
mainfrom
claude/issue-16042-analytics-timezone

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #16042

packages/drivers/driver-memory/src/memory-analytics.ts declared AnalyticsQuery.timezone on its way in and never read it, so dateRange: 'today' was answered on the UTC calendar for a caller who had said which calendar they meant. Direction chosen: the driver HONOURS the field.

Why honour it rather than stop advertising it

The card states the choice as binary — honour it, or stop advertising it on this path — and "a third state where it is accepted and ignored is the one that misleads." Stopping the advertisement is not available to this card, and not merely by dispatch fence:

  • The declaration lives in packages/spec/src/data/analytics.zod.ts, another lane this card does not edit.
  • Withdrawing it at the driver would mean REFUSING a query that carries timezone, which every current caller passes legitimately — service-analytics' buildQuery writes a timezone into every AnalyticsQuery it builds (selection.timezone ?? contextTimezone ?? 'UTC', dataset-executor.ts:1121). A refusal there would reject the platform's own normal traffic.

So the repair is agreement, and the driver owes the last two links of the declared chain: the value it was handed, else UTC.

Before / after row sets — a non-UTC zone

Asia/Shanghai, clock frozen at 2026-09-06T20:00:00Z (04:00 on 2026-09-07 in Shanghai), dateRange: 'today', eight probe rows. Both row sets are asserted against the real MemoryAnalyticsService.query() entry in the same test, so the "before" is measured, not recalled.

window rows returned
before [2026-09-06T00:00:00.000Z, 2026-09-07T00:00:00.000Z) — the UTC day 06T00:00:00.000Z · 06T15:59:59.999Z · 06T16:00:00.000Z · 06T23:59:59.999Z
after [2026-09-06T16:00:00.000Z, 2026-09-07T16:00:00.000Z) — Shanghai's day 06T16:00:00.000Z · 06T23:59:59.999Z · 07T04:00:00.000Z · 07T15:59:59.999Z

Four rows either way, two of the four different: 06T00:00:00.000Z and 06T15:59:59.999Z leave (yesterday in Shanghai), 07T04:00:00.000Z and 07T15:59:59.999Z join (today in Shanghai). This is the change the card warned is not a drive-by.

A query carrying no timezone is unchanged by construction, and #15825's two pins stay green (52 tests across the three date-range files).

The repair has two halves and each fails silently alone

  1. WHICH calendar day 'today' is — calendarPartsInTzOrUtc(now, tz), then arithmetic on a UTC proxy day (the proxyDay() pattern in packages/core/src/utils/filter-tokens.ts:167).
  2. WHERE that day begins as an instantzonedDateStartToUtcMs(ymd, tz), that zone's local midnight.

Half 2 is required because the bounds here are rendered with toISOString() and compared against datetime values. That is exactly the case ADR-0053 already settles, in the only other place in this tree that turns a reference timezone into an instant bound — packages/services/service-analytics/src/analytics-service.ts:1271-1302: const rangeTz = selection.timezone ?? context?.timezone ?? 'UTC', then "datetime → the reference tz's MIDNIGHT INSTANT (ISO), because the bucket is defined on that tz's calendar", with the bare YYYY-MM-DD calendar bound reserved for a date-typed, tz-naive column.

⛔ Half 1 alone is a silent wrong answer, and the pin proves it: see ablation B below, where Asia/Kolkata degenerates to exactly the UTC row set.
⛔ The end bound is a calendar step, never + 86_400_000: on America/New_York, 2026-03-08 begins at 05:00Z and 2026-03-09 at 04:00Z — a 23-hour day.

Anchors and real line numbers (at 1328902ce)

what where
the defect / the repair packages/drivers/driver-memory/src/memory-analytics.ts:1415parseDateRangeString(range, timezone)
the only call site same file, :742 — now passes query.timezone
the declaration packages/spec/src/data/analytics.zod.ts:347 (timezone: z.string().optional(), doc at :337-346) — ⛔ not edited
the declared chain, resolved packages/services/service-analytics/src/dataset-executor.ts:1121
the chain for an instant bound packages/services/service-analytics/src/analytics-service.ts:1271
the primitives packages/core/src/utils/datetime.ts:62 and :94
proxyDay() packages/core/src/utils/filter-tokens.ts:167
the pin packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts

The card's own re-check recipe self-falsifies — re-checked correctly

The card says git grep -n 'timezone' -- .../memory-analytics.ts → 0 hits (at 2024eca4f). Today it returns 5, and all five are comments added by #15825, one of them literally "⚠️ Out of scope here, filed separately" pointing at this card. Re-checked by searching for a READ instead: git grep -nE "\.timezone|timezone\s*[:)]" hit only those same two comment lines; control dateRange = 4 in the same file, so the grep fires. Zero reads confirmed.

Verification

  • Pin: 14 cases over 7 zones — both offset signs, three non-whole-hour offsets (Kolkata +05:30, Chatham +12:45), both tz-database extremes (Kiritimati +14:00, Niue −11:00), one cell sharing UTC's calendar day, and one 23-hour spring-forward day. Every window literal was computed independently of the code under test, from Intl.DateTimeFormat alone.
  • Suites: pnpm --filter @objectstack/driver-memory exec vitest run over the new pin + memory-analytics-date-range-utc-window + memory-analytics-date-range-dst + memory-analytics74 passed / 74.
  • Ablation A (remove the timezone read at the call site): 9 failures, all in the new pin; driver-memory analytics parseDateRangeString() builds its window on the LOCAL calendar and renders it as UTC — 'today' and 'last N days' are offset by the process timezone in every non-UTC zone #15825's two pins stayed green — the new pin does not overlap their territory. Mutation proved on disk (blob e2a48c2fbe1295d0); restore proved (blob back to the HEAD blob, git diff HEAD empty).
  • Ablation B (proxyDay()-only — resolve the day in the zone, cut it at UTC midnight): 9 failures. Asia/Kolkata returned [06T00:00:00.000Z … 06T23:59:59.999Z] — the UTC day exactly, which is what makes half 2 load-bearing rather than decorative. Mutation and restore proved the same way.
  • Gates: all 54 families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (43 by path + 6 by change kind + 7 whole-tree, 2 reached both ways), run with redirect-then-read at head 1328902ce54/54 exit 0. The anchor/census family is included because this diff changes line counts: check-system-context-census reports "105 elevation read sites … all anchored; 140 anchors resolve". check:dts-closure swept 71 built packages and packages/drivers/driver-memory/dist/index.d.ts exists, so its green is a statement about this package.

Clause ② — no, re-derived from this diff

Instrument: build at head → revert memory-analytics.ts to origin/main → rebuild (tsup directly, no turbo cache on the path) → diff every file files[] publishes → restore byte-exact. Rebuild proved by mtime movement (index.d.ts 178866749817886683591788668363) with the .js hash genuinely changing between snapshots.

published file result classification
dist/index.d.ts byte-identical (0dc3accc… all three snapshots)
dist/index.d.mts byte-identical (same hash)
dist/index.js, dist/index.mjs differ implementation body only: a call site gains an argument, a private method gains a parameter, the boundary computation changes. No exported symbol, no signature.
dist/index.js.map, dist/index.mjs.map differ sourcesContent embeds memory-analytics.ts verbatim, so every touched comment appears there. Not a published contract face.

⛔ The byte-identical declaration is not taken on faith — the two known false negatives were excluded by measurement, not by shape:

  • Not a barrel: dist/index.d.ts is 1031 lines with 23 export/declare lines and 0 re-export-only lines. Firing control: MemoryAnalyticsService appears in it.
  • No shared chunk: dist/ is exactly 6 files, all index.*. There is no chunk for a member to hide in.
  • Why it is identical, mechanically: the only edited member is private parseDateRangeString, and TypeScript emits a private member as a bare name with no signature — private parseDateRangeString; at dist/index.d.ts:769, identical line and position in both snapshots. The added parameter is invisible to the declaration by construction.
  • The new test file reaches no published artefact: both sourcemaps list 11 sources, 0 of them .test./.spec., and neither names date-range-timezone.

Limb 2 — is any request newly accepted or rejected? No. AnalyticsQuerySchema is untouched, so every query that parsed before parses now. The driver adds no refusal path, no new error code and no new throw: both primitives degrade an unset, 'UTC', or unknown zone to UTC rather than raising (measured live — Mars/Olympus yields UTC parts and UTC midnight), and the pin asserts a bogus zone still returns an answer. A query that was answered with UTC rows and is now answered with Shanghai rows is a change to the value returned for an accepted request, not to the accept set — which is why this needs a minor changeset and the before/after table above, and is still a clause-② no.

Docs drift — run by hand, because the check declares itself blind here

Docs Drift Check returned "nothing to list"; that is not a clean bill of health, so I ran the tool and then hand-searched. node scripts/docs-audit/affected-docs.mjs --json <merge-base> on a clean tree (dirty: false, head 1328902ce, base 94ecb7e5c) says: anchors = exactly one, parseDateRangeString (symbol, a method of MemoryAnalyticsService); docs = []; anchorlessChanges = []; overbroadAnchors = []; testFilesSkipped = 1. ⭐ weakAnchorsDropped = ["query (symbol)"] — the name too generic to anchor is query, the public method whose body carries the changed call site. It is not timezone, which is the guess this shape invites.

Hand-search of content/docs (plain directory pathspec; 190 hand-written .mdx outside content/docs/references/**), since the check sees only pages that NAME an anchor:

  • No page documents the timeDimensions[].dateRange relative-token vocabulary at all. dateRange appears in 5 hand-written pages, none about this path; the three analytics pages (data-modeling/analytics.mdx, capabilities/analytics.mdx, ui/reports.mdx) contain 0 occurrences of dateRange, timezone, UTC, timeDimension or calendar — with firing controls on each (analytics → 8 / 1 / 10).
  • The rule-by-its-INPUTS shape, checked deliberately: content/docs/ui/dashboards.mdx is the one page that names range tokens (today, this_month, last_7_days, {today}, {30_days_ago}). It makes no calendar claim whatsoeverutc 0, timezone 0, time zone 0, calendar 0 in that file (control: today → 2). So there is nothing there for this change to falsify. Its vocabulary is also a different one: dashboard presets are underscored (last_7_days) and {today} is a core filter-token macro, whereas this path parses spaced strings ('last 7 days').
  • Negative claims that would be falsified: twelve phrasings searched (always UTC, always resolved in UTC, interpreted as UTC, ignores timezone, UTC-only, …) → 0 hits each, against a firing control (UTC → 35 hand-written pages). The 4 not honou hits are MySQL upsert, email transport and boot assertions — unrelated.
  • The one live UTC claim, and why it survives: content/docs/data-modeling/queries.mdx:668-673 says "Buckets are computed in UTC. A non-UTC reference timezone is only reachable through ObjectQL.aggregate(object, { …, timezone })". That is scoped to groupBy / dateGranularity bucketing on the ObjectQL path — a different path from the cube timeDimensions[].dateRange window this PR changes. Measured support rather than asserted: git grep -n granularity -- packages/drivers/driver-memory/src/memory-analytics.ts returns 0MemoryAnalyticsService does no bucketing at all. Not falsified, and not edited.
  • The midnight UTC claims in protocol/objectql/query-syntax.mdx and data-modeling/queries.mdx are ADR-0053's bare-YYYY-MM-DD whole-day rule for filter comparands. Untouched: nextUtcCalendarDay still returns null for a full ISO timestamp, so the widening rule is unchanged.

No documentation edit is owed by this PR, and nothing under content/docs/releases/** was touched. The real gap the drift check is pointing at is that this vocabulary is undocumented — reported to the PM rather than fixed here, since writing that page is not this card.

Scope

🤖 Generated with Claude Code

https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ


Generated by Claude Code

…mezone` when resolving a string `dateRange` (#16042)

`parseDateRangeString()` accepted the field and never read it, so a caller
asking `dateRange: 'today'` with `timezone: 'Asia/Shanghai'` was answered on
the UTC day — accepted, unwarned, and silently ignoring the field they set.
The schema declares `timezone` optional with no default precisely because an
absent value is a meaningful state the engine resolves
(`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2), and
`service-analytics` resolves that whole chain into `query.timezone` before a
driver sees it.

Two halves, each silent on its own and each pinned: the zone decides WHICH
calendar day `'today'` is (`calendarPartsInTzOrUtc`, the `proxyDay()` pattern)
and WHERE that day begins as an instant (`zonedDateStartToUtcMs` — that zone's
local midnight, which is what ADR-0053 already specifies for a `datetime` bound
in `service-analytics`' drill ranges). Half one alone anchors to the zone's
calendar day and then cuts it at UTC midnight: a window that is neither day.
The end bound is a calendar step, never `+ 86_400_000` — on `America/New_York`
2026-03-08 is 23 hours long.

A query carrying no timezone is unchanged by construction, and #15825's two
pins stay green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
@github-actions github-actions Bot added the size/m label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 07f40e5463a98d43f0106e34ab7ebb3a44d1de06packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8fed2e63a5e602e0705946579954e0183dbd7a1c — the merge of head 1328902ce92e6795aa3f190a296e16ad3eee648f into base 07f40e5463a98d43f0106e34ab7ebb3a44d1de06, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8fed2e63a5e602e0705946579954e0183dbd7a1c && git checkout 8fed2e63a5e602e0705946579954e0183dbd7a1c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 07f40e5463a98d43f0106e34ab7ebb3a44d1de06 1328902ce92e6795aa3f190a296e16ad3eee648f && git checkout -B drift-repro 07f40e5463a98d43f0106e34ab7ebb3a44d1de06 && git merge --no-ff 1328902ce92e6795aa3f190a296e16ad3eee648f

node scripts/docs-audit/affected-docs.mjs --json 07f40e5463a98d43f0106e34ab7ebb3a44d1de06

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 6, 2026 05:07
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 1cf7392 Sep 6, 2026
38 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-16042-analytics-timezone branch September 6, 2026 05:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-memory analytics resolves a string dateRange without ever reading the declared AnalyticsQuery.timezone

2 participants